fix(swiftpm): persist --config-command so the in-build sync keeps using it - #57756
Closed
chrfalch wants to merge 2 commits into
Closed
fix(swiftpm): persist --config-command so the in-build sync keeps using it#57756chrfalch wants to merge 2 commits into
chrfalch wants to merge 2 commits into
Conversation
`spm add --config-command '<argv>'` succeeded and wrote a valid project, then
every subsequent Xcode build failed: the injected "Sync SPM Autolinking" phase
re-derives autolinking.json on each build, had no knowledge of the flag, and
fell back to `npx --no-install @react-native-community/cli config` — which an
app that replaces CLI autolinking (an Expo app, say) does not have. A
successful `spm add` therefore produced an unbuildable project. Reported by the
Expo team while verifying SwiftPM.
Pin the command into the `.spm-injected.json` marker at add/update time and read
it back on later runs, mirroring the neighbouring `artifactsVersionOverride`
set-or-preserve pin. Resolution order is unchanged at the front and only
extended at the back:
--config-command -> RCT_SPM_AUTOLINKING_CONFIG_COMMAND -> pin -> default
Both input routes persist. The help text advertises the env var as an
equivalent way to supply the command, so pinning only the flag would have left
half the documented interface broken the same way; the pin stores the resolved
command from either route.
Two details worth knowing:
- generateAutolinkingConfig resolves the env var internally when no explicit
command is passed, so handing it the pin would silently outrank a developer's
env override. The read path withholds the pin while the env var is set and
lets the existing precedence apply. A whitespace-only env var pins nothing and
falls through, using the same blankness predicate as the resolver so the two
cannot drift.
- A pinned value is re-validated through the same parseConfigCommandJson the
flag goes through, so a hand-edited or corrupt marker degrades to the
env/default command rather than injecting a bogus argv into a build.
Because this is persistent state, add/update logs one line when the command
comes from the pin, naming .spm-injected.json, so a stale pin is diagnosable
from build output instead of invisible. There is no "clear" verb short of
`deinit`, as with the version pin.
Also corrects two comments asserting that the build-time sync reads
`readArtifactsVersionOverride`. It does not — that reader has no production
caller, so only the write half of the version pin is wired. Wiring it up is a
separate change; the comments are fixed here because they are actively
misleading.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrfalch
marked this pull request as draft
July 29, 2026 17:26
chrfalch
marked this pull request as ready for review
July 30, 2026 08:44
## Summary: The `--config-command` flag has never been documented, and the pin that keeps the in-build sync using it was added without a docs change. Cover both in spm-scripts.md: - a `--configCommand <json>` row in CLI Options, naming `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` as the equivalent env var, - a short section giving the resolution order (flag -> RCT_SPM_AUTOLINKING_CONFIG_COMMAND -> the `configCommand` pinned in `.spm-injected.json` -> the default `@react-native-community/cli config`) and why the pin has to exist: the Sync SPM Autolinking phase inherits neither the flag nor the shell that exported the env var, so an unpinned command turns a successful `add` into failing builds. Also states that `deinit` drops the marker and the pin with it, - the marker's dual role in the "What to commit" table: reversal record *and* pinned configuration, - a Troubleshooting row keyed on the symptom people will search for — the build phase failing with `@react-native-community/cli config` exiting non-zero. Docs only; no behavior change. Deliberately not reformatted: this file is not Prettier-formatted on this branch, and reformatting would bury the change. ## Changelog: [Internal] - Document `spm --configCommand` and how the autolinking config command is persisted ## Test Plan: Docs only — nothing to run. Every statement was checked against the code on this branch: the marker field name (`configCommand` in `generate-spm-xcodeproj.js`), both input routes persisting (`resolveConfigCommandToPin` = flag ?? env), the pin never shadowing the env var (`resolveExplicitConfigCommand`), the actions that read it (`needsCliConfig` covers add/update/sync/scaffold), and the failure being a hard build error (config-command failure sets exit 2, which the generated build phase turns into `exit 1`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cipolleschi
approved these changes
Jul 31, 2026
|
@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D114317929. |
|
@cipolleschi merged this pull request in ed3229a. |
fabriziocucci
pushed a commit
that referenced
this pull request
Aug 3, 2026
…ng it (#57756) Summary: **`spm add --config-command` worked once, then broke every build.** An app that replaces `react-native-community/cli` autolinking — an Expo app, for instance — has to override the autolinking config command, which `--config-command` (and `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`) exists to do. But the flag was never stored anywhere. So: 1. `npx react-native spm add --config-command '[...]'` → succeeds, writes a valid project ✅ 2. Build in Xcode → the "Sync SPM Autolinking" phase re-derives `autolinking.json`, doesn't know about the flag, falls back to the default command, and fails ❌ ``` PhaseScriptExecution failed with a nonzero exit code → Sync SPM Autolinking → 'npx --no-install react-native-community/cli config' exited with status 1 ``` The only real workaround was exporting `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` into Xcode's environment — i.e. committing it to `.xcode.env` — which shouldn't be necessary when you already passed a flag. Reported by the Expo team while testing SwiftPM. **Fix:** pin the command into the `.spm-injected.json` marker at `add`/`update` time, and read it back on later runs — the same set-or-preserve pin the neighbouring `artifactsVersionOverride` already uses. Resolution order, unchanged at the front and only extended at the back: ``` --config-command → RCT_SPM_AUTOLINKING_CONFIG_COMMAND → pinned value → default ``` **Both input routes persist.** The help text advertises the env var as an equivalent way to supply the command, so pinning only the flag would have left half the documented interface broken in exactly the same way — export the env var, run `spm add`, and the build phase (which does not inherit your shell) still fails. The pin therefore stores the *resolved* command from either route. Two subtleties worth a reviewer's eye: - `generateAutolinkingConfig` resolves the env var *internally* when no explicit command is passed, so handing it the pin would silently outrank a developer's env override. The read path therefore **withholds** the pin while the env var is set, letting the existing precedence do its job. The four order cases are tested, including that a whitespace-only env var falls through to the pin rather than stranding it. - A pinned value is re-validated through the same `parseConfigCommandJson` the flag goes through, so a hand-edited or corrupt marker degrades to the env/default command instead of injecting a bogus argv into a build. Because this is now persistent state, `add`/`update` logs one line when the command comes from the pin, naming `.spm-injected.json` — a stale pin should be diagnosable from build output rather than invisible. There is no "clear" verb short of `deinit`, same as the version pin; that is noted in the marker comment. 122 added lines across three source files. No new mechanism, no changes to the sync scripts, and nothing baked into the generated build phase. **Noticed while here, filed separately, deliberately not fixed:** `readArtifactsVersionOverride` — the version pin this is modelled on — has **no production caller**. Only its write half is wired, and two comments claim the build-time sync reads it. Those comments are corrected here (they misled me while writing this); wiring the version pin up is its own change. ### This isn't blocking anyone Expo's SwiftPM verification is **not blocked** on this, so it needn't be rushed. The env-var half of the override already works at build time: adding ```sh export RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["node","…/expo-modules-autolinking.js","react-native-config","--json","--platform","ios"]' ``` to the app's `.xcode.env` (or `.xcode.env.local`) gets the command into the sync phase, because the generated phase sources both files before dispatching. That is what unblocks Expo today, and it is exactly the "commit an env var to `.xcode.env`" step this PR removes the need for. One caveat that argues for fixing it properly rather than documenting the workaround: the phase sources `.xcode.env` **only when `NODE_BINARY` is unset** (`nodeAndRnDirPreamble`). An app that sets `NODE_BINARY` as an Xcode build setting — a documented RN practice — never sources those files, so the workaround silently does nothing there and the build fails with no hint as to why. Persisting the flag doesn't depend on any of that plumbing. ## Changelog: [Internal] [Fixed] - SwiftPM: persist `spm --config-command` so the in-build autolinking sync keeps using it Pull Request resolved: #57756 Test Plan: `yarn jest packages/react-native/scripts` → **31 suites, 703 tests** (25 new). Each new test written red first, covering: - the command round-trips through `.spm-injected.json` (marker content asserted) - `add` → `update` **without** the flag keeps the pin; a later flag overwrites it - `add` with **only the env var** set pins the env-derived command, and a later run with neither flag nor env resolves it back - all four resolution-order cases, including that **the env var beats the pin**, and that a whitespace-only env var pins nothing and falls through - an invalid non-blank env var still fails loud rather than pinning garbage - a corrupt or hand-edited pin (bare string, `[]`, non-string member, empty-string member, object) degrades to the default rather than throwing - `deinit` drops it with the marker Not covered: no test drives a real `sync` end to end, since that needs artifacts, codegen and a real pbxproj. The two halves are tested separately against the same marker field — the injector writes `configCommand`, and the resolver reads it. The original failure was reported from a real Expo app build; confirmation that this fixes that build is still pending on the Expo side. Reviewed By: fabriziocucci Differential Revision: D114317929 Pulled By: cipolleschi fbshipit-source-id: a0fe47de3da55b25e320b000a2b0b1b44b88af9a (cherry picked from commit ed3229a)
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 10, 2026
Summary:
**`spm add --version <ver>` pinned a value that nothing ever read back.**
The marker write has been there all along, and so has the reader — `readArtifactsVersionOverride()` in `spm/generate-spm-xcodeproj.js`, exported and unit-tested. It just had **zero production callers**. Every reference to it was a test.
`determineVersion` — the only resolver — went straight from the flag to `package.json`:
```js
let version = args.version; // --version
if (version == null) {
version = pkgJson.version; // react-native/package.json; marker never consulted
}
```
That value picks which artifact slots the project gets wired to. So:
1. `spm add --version 0.88.0-nightly-…` → wires the nightly's slots, pins the label ✅
2. `spm update` (no flag) → silently resolves `package.json`'s version instead, re-pointing the project at different slots, while the marker still claims the nightly ❌
`--version` was effectively single-use. In this monorepo `package.json` is `1000.0.0`, which has no published artifacts, so a flagless run after a pinned `add` fails outright — which is why the standing advice has been to pass `--version` on *every* invocation. That advice was working around this bug.
**Fix:** insert the pin between the two existing sources.
```
--version → pinned override → react-native/package.json
```
15 lines of logic. `spm download` and the scaffold path pick it up for free, since both consume the same resolved value. Because this is persistent state, one line is logged when the pin is the source, so a stale pin is diagnosable instead of silent; there is still no way to clear it short of `deinit`.
**Three comments were actively wrong** and are corrected here: the reader's doc block, the marker-field comment, and `findInjectedXcodeproj`'s comment all asserted that the build-time sync calls this via `readArtifactsVersionOverride`. It does not and never did — the `sync` action returns before artifacts are resolved at all. Those comments are what made the gap invisible; they misled me while investigating.
## Changelog:
[Internal] [Fixed] - SwiftPM: `spm --version` now sticks, so a later run without the flag keeps using the pinned artifact version
Pull Request resolved: #57762
Test Plan:
`yarn jest packages/react-native/scripts` → **31 suites, 684 tests**, all green.
New tests, written red first — the load-bearing one failed with `Expected: "0.80.0" / Received: "1000.0.0"`, i.e. exactly the reported bug:
- `--version` given → wins, even with a different value pinned
- no flag, pin present → the pinned version is used
- no flag, no pin → `package.json`'s version (unchanged behaviour)
- no flag, corrupt or absent marker → `package.json`'s version, no throw
- the log line fires only when the pin is the source
The three fallback cases passed *before* the fix too, which is the point — they pin today's behaviour so this change can't regress it.
## Note on landing order
Touches `setup-apple-spm.js` and `spm/generate-spm-xcodeproj.js`, which #57744, #57756 and #57757 also touch. All are cut independently from `main`; whichever lands first, I'll rebase the rest. #57756 is the closest relative — it does the same wiring for `--config-command`, which had the identical write-only-pin shape.
Reviewed By: fabriziocucci
Differential Revision: D114318107
Pulled By: cipolleschi
fbshipit-source-id: 157da5b2eaaef9adee924eaa1832b7a38b008f4c
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 10, 2026
…es (#57757) Summary: **SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that generates content at build time can't get one.** The first casualty is expo-constants: nothing writes `EXConstants.bundle/app.config`, which shows up at runtime as *"Unable to find the embedded app config"*. This adds a 6th field to the SwiftPM autolinking plugin contract: ```js scriptPhases: [{ id: 'expo-constants.app-config', // stable: ledger key + deterministic UUID seed name: 'Generate Expo app.config', // Xcode's display name script: '…', position: 'end', // 'end' (default) | 'beforeCompile' inputPaths: ['$(SRCROOT)/../app.json'], outputPaths: ['$(TARGET_BUILD_DIR)/…/EXConstants.bundle/app.config'], alwaysOutOfDate: true, }] ``` The plugin returns data; RN validates it, records it to a `.spm-plugin-script-phases.json` sidecar (written even when empty, so removing a plugin clears stale entries), and `spm add`/`update` emits one `PBXShellScriptBuildPhase` per entry — tracked in `.spm-injected.json` by `id`, so `update` reconciles and `deinit` reverts. ### Verified end to end by the Expo team On a real Expo app, against a local cut of this branch. A `position: 'end'` phase lands last, after the JS bundle phase: ``` 5. Resources 6. Bundle React Native code and images 7. [Expo Dev Launcher] Strip Local Network Keys for Release 8. Generate Expo app.config ← last ``` `BUILD SUCCEEDED`, and `EXConstants.bundle/app.config` is written with `sdkVersion: 56.0.0` — precisely the value whose absence caused the original bug. Red baseline confirmed first: before the declaration, the same app built with `0 script phase(s)`, an empty sidecar, and no `EXConstants.bundle` at all. They also independently confirmed `deinit` leaves zero residue, `add` is idempotent (same sha1 twice), and no phase duplicates. Their side is expo/expo#47647. ### Design decisions worth a reviewer's attention - **`end` appends at the true end of `buildPhases`**, which is *after* the JS bundle phase — where expo-constants must write, since it targets `$TARGET_BUILD_DIR`. Anchoring relative to the Frameworks phase (the obvious-looking choice) lands it *before* the bundle phase, because real template order is `Sources, Frameworks, Resources, Bundle React Native code and images`. - **`beforeCompile` anchors after RN's own "Sync SPM Autolinking" phase**, which must stay first since it regenerates autolinking — a plugin phase ahead of it would run against stale generated content. The anchor chains forward so declared order survives. Position and relative order are re-derived every sync, so a phase dragged by hand in Xcode returns to its declared slot. - **Validation is fatal**, matching `flavoredFrameworks` rather than the lenient `watchPaths`. A silently dropped phase means the content is never written and the app fails at runtime with no build-time signal — which is the bug being fixed. - **`id` is the ledger key and the UUID seed.** The charset allows a scoped npm name (`expo/log-box`) but excludes `:`, which separates the `plugin:<id>` seed. `__proto__`/`constructor`/`prototype` are rejected because `plainObject['__proto__'] = v` vanishes through `JSON.stringify`, which would record a phase that `deinit` could never remove. - **A plugin-supplied `name` reaches pbxproj comments**, and those are scanned by single-line regexes. What lands in a comment is therefore normalized: a name containing `*/`, `{` or `,` otherwise produced a brace-unbalanced project Xcode couldn't open, or an orphan phase `deinit` reported removing but didn't. The full name still goes verbatim into the `name` field Xcode displays. The same normalization now covers generated-source filenames, which had the identical hole. ### Also fixed in passing `add → update → deinit` did **not** restore `project.pbxproj` byte-for-byte, even with zero script phases: the second run's marker forgot what the first had created, leaving an empty `packageReferences` / `packageProductDependencies` and the generated `.xcscheme` behind. The created-record now carries forward and `scheme.created` is sticky. Two guards came with that, both tested: a created array field is removed only when it is **empty** after RN's own members come out (so a package a user added to it survives `deinit`), and the scheme is deleted only if it is still RN's own (so a scheme the user has taken over is left alone). ## Changelog: [Internal] [Added] - SwiftPM: autolinking plugins can declare build-time script phases via `scriptPhases` Pull Request resolved: #57757 Test Plan: `yarn jest packages/react-native/scripts` → **853 tests**, all green. The SwiftPM suite specifically went from **462 → 637** tests. Written red first throughout. Coverage includes: - one declared phase → exactly one `PBXShellScriptBuildPhase`, correct `name`/`shellScript`/serialized paths; `alwaysOutOfDate` emitted as unquoted `1` only when set - `end` lands last; `beforeCompile` lands after the sync phase and before Sources; declared order preserved for multiple phases of each position and for a mix; a changed `position` is re-seated on the next sync - add / update-in-place / remove keyed on `id`; unchanged re-sync byte-identical; `deinit` byte-identical with no orphan object or section - a 17-row hostile-`name` matrix (`{ } ( ) , ; = */ /* * /`, unbalanced quote, tab, unicode, 300 chars, a name that normalizes to empty) × {balanced after add, byte-identical re-inject, clean deinit} - scripts containing quotes, backslashes, newlines and `$(VAR)` round-trip through emission, refresh and deinit - contract validation: 16 malformed-entry cases, duplicate `id` across plugins, reserved ids, scoped ids accepted, `:` rejected - `add → update → deinit` byte-identity with zero phases and with two **Not covered by unit tests, deliberately:** that `end` runs after the JS bundle phase. The `plain-app.pbxproj` fixture has no bundle phase, so it is unassertable here — this is disclosed in a comment at the test rather than papered over, and is exactly what the Expo verification above establishes. **Known limitation, not addressed here:** against a project Xcode has previously saved, `add → deinit → add` is structurally identical (same UUIDs, same reference counts) but not byte-identical — Xcode writes multi-line dicts in sorted order, the injector writes single-line dicts in insertion order, which shows up as formatting churn in a committed `project.pbxproj`. Pre-existing for every object the injector emits, not specific to script phases, and filed separately. ## Note on landing order This touches `generate-spm-xcodeproj.js` and `spm-pbxproj.js`, which #57744 and #57756 also touch — including the same marker-write block. All three are cut independently from `main`; whichever lands first, I'll rebase the others. Happy to restack in whatever order is easiest to review. Reviewed By: fabriziocucci Differential Revision: D114318236 Pulled By: cipolleschi fbshipit-source-id: 4aa7958c302323299eda9db17adcec0d86edeebe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
spm add --config-commandworked once, then broke every build.An app that replaces
@react-native-community/cliautolinking — an Expo app, for instance — has to override the autolinking config command, which--config-command(andRCT_SPM_AUTOLINKING_CONFIG_COMMAND) exists to do. But the flag was never stored anywhere. So:npx react-native spm add --config-command '[...]'→ succeeds, writes a valid project ✅autolinking.json, doesn't know about the flag, falls back to the default command, and fails ❌The only real workaround was exporting
RCT_SPM_AUTOLINKING_CONFIG_COMMANDinto Xcode's environment — i.e. committing it to.xcode.env— which shouldn't be necessary when you already passed a flag. Reported by the Expo team while testing SwiftPM.Fix: pin the command into the
.spm-injected.jsonmarker atadd/updatetime, and read it back on later runs — the same set-or-preserve pin the neighbouringartifactsVersionOverridealready uses.Resolution order, unchanged at the front and only extended at the back:
Both input routes persist. The help text advertises the env var as an equivalent way to supply the command, so pinning only the flag would have left half the documented interface broken in exactly the same way — export the env var, run
spm add, and the build phase (which does not inherit your shell) still fails. The pin therefore stores the resolved command from either route.Two subtleties worth a reviewer's eye:
generateAutolinkingConfigresolves the env var internally when no explicit command is passed, so handing it the pin would silently outrank a developer's env override. The read path therefore withholds the pin while the env var is set, letting the existing precedence do its job. The four order cases are tested, including that a whitespace-only env var falls through to the pin rather than stranding it.parseConfigCommandJsonthe flag goes through, so a hand-edited or corrupt marker degrades to the env/default command instead of injecting a bogus argv into a build.Because this is now persistent state,
add/updatelogs one line when the command comes from the pin, naming.spm-injected.json— a stale pin should be diagnosable from build output rather than invisible. There is no "clear" verb short ofdeinit, same as the version pin; that is noted in the marker comment.122 added lines across three source files. No new mechanism, no changes to the sync scripts, and nothing baked into the generated build phase.
Noticed while here, filed separately, deliberately not fixed:
readArtifactsVersionOverride— the version pin this is modelled on — has no production caller. Only its write half is wired, and two comments claim the build-time sync reads it. Those comments are corrected here (they misled me while writing this); wiring the version pin up is its own change.This isn't blocking anyone
Expo's SwiftPM verification is not blocked on this, so it needn't be rushed. The env-var half of the override already works at build time: adding
to the app's
.xcode.env(or.xcode.env.local) gets the command into the sync phase, because the generated phase sources both files before dispatching. That is what unblocks Expo today, and it is exactly the "commit an env var to.xcode.env" step this PR removes the need for.One caveat that argues for fixing it properly rather than documenting the workaround: the phase sources
.xcode.envonly whenNODE_BINARYis unset (nodeAndRnDirPreamble). An app that setsNODE_BINARYas an Xcode build setting — a documented RN practice — never sources those files, so the workaround silently does nothing there and the build fails with no hint as to why. Persisting the flag doesn't depend on any of that plumbing.Changelog:
[Internal] [Fixed] - SwiftPM: persist
spm --config-commandso the in-build autolinking sync keeps using itTest Plan:
yarn jest packages/react-native/scripts→ 31 suites, 703 tests (25 new).Each new test written red first, covering:
.spm-injected.json(marker content asserted)add→updatewithout the flag keeps the pin; a later flag overwrites itaddwith only the env var set pins the env-derived command, and a later run with neither flag nor env resolves it back[], non-string member, empty-string member, object) degrades to the default rather than throwingdeinitdrops it with the markerNot covered: no test drives a real
syncend to end, since that needs artifacts, codegen and a real pbxproj. The two halves are tested separately against the same marker field — the injector writesconfigCommand, and the resolver reads it. The original failure was reported from a real Expo app build; confirmation that this fixes that build is still pending on the Expo side.